# Copyright (c) 2012, the Dart project authors.  Please see the AUTHORS file
# for details. All rights reserved. Use of this source code is governed by a
# BSD-style license that can be found in the LICENSE file.

# This file contains a set of utilities functions used by other Python-based
# scripts.

import contextlib
import datetime
import glob
import imp
import json
import os
import platform
import re
import shutil
import subprocess
import sys
import tarfile
import tempfile
import uuid

try:
  # Not available on Windows.
  import resource
except:
  pass

DART_DIR = os.path.abspath(
    os.path.normpath(os.path.join(__file__, '..', '..')))


def GetBotUtils():
  '''Dynamically load the tools/bots/bot_utils.py python module.'''
  return imp.load_source('bot_utils', os.path.join(DART_DIR, 'tools', 'bots', 'bot_utils.py'))


def GetMinidumpUtils():
  '''Dynamically load the tools/minidump.py python module.'''
  return imp.load_source('minidump', os.path.join(DART_DIR, 'tools', 'minidump.py'))


class Version(object):
  def __init__(self, channel, major, minor, patch, prerelease,
               prerelease_patch, abi_version, oldest_supported_abi_version):
    self.channel = channel
    self.major = major
    self.minor = minor
    self.patch = patch
    self.prerelease = prerelease
    self.prerelease_patch = prerelease_patch
    self.abi_version = abi_version
    self.oldest_supported_abi_version = oldest_supported_abi_version


# Try to guess the host operating system.
def GuessOS():
  os_id = platform.system()
  if os_id == "Linux":
    return "linux"
  elif os_id == "Darwin":
    return "macos"
  elif os_id == "Windows" or os_id == "Microsoft":
    # On Windows Vista platform.system() can return "Microsoft" with some
    # versions of Python, see http://bugs.python.org/issue1082 for details.
    return "win32"
  elif os_id == 'FreeBSD':
    return 'freebsd'
  elif os_id == 'OpenBSD':
    return 'openbsd'
  elif os_id == 'SunOS':
    return 'solaris'
  else:
    return None


# Try to guess the host architecture.
def GuessArchitecture():
  os_id = platform.machine()
  if os_id.startswith('armv5te'):
    return 'armv5te'
  elif os_id.startswith('armv6'):
    return 'armv6'
  elif os_id.startswith('arm'):
    return 'arm'
  elif os_id.startswith('aarch64'):
    return 'arm64'
  elif '64' in os_id:
    return 'x64'
  elif (not os_id) or (not re.match('(x|i[3-6])86', os_id) is None):
    return 'ia32'
  elif os_id == 'i86pc':
    return 'ia32'
  else:
    guess_os = GuessOS()
    print ("Warning: Guessing architecture %s based on os %s\n"\
          % (os_id, guess_os))
    if guess_os == 'win32':
      return 'ia32'
    return None


# Try to guess the number of cpus on this machine.
def GuessCpus():
  if os.getenv("DART_NUMBER_OF_CORES") is not None:
    return int(os.getenv("DART_NUMBER_OF_CORES"))
  if os.path.exists("/proc/cpuinfo"):
    return int(subprocess.check_output("grep -E '^processor' /proc/cpuinfo | wc -l", shell=True))
  if os.path.exists("/usr/bin/hostinfo"):
    return int(subprocess.check_output('/usr/bin/hostinfo |'
        ' grep "processors are logically available." |'
        ' awk "{ print \$1 }"', shell=True))
  win_cpu_count = os.getenv("NUMBER_OF_PROCESSORS")
  if win_cpu_count:
    return int(win_cpu_count)
  return 2


def GetWindowsRegistryKeyName(name):
  import win32process
  # Check if python process is 64-bit or if it's 32-bit running in 64-bit OS.
  # We need to know whether host is 64-bit so that we are looking in right
  # registry for Visual Studio path.
  if sys.maxsize > 2**32 or win32process.IsWow64Process():
    wow6432Node = 'Wow6432Node\\'
  else:
    wow6432Node = ''
  return r'SOFTWARE\%s%s' % (wow6432Node, name)


# Try to guess Visual Studio location when buiding on Windows.
def GuessVisualStudioPath():
  defaultPath = r"C:\Program Files (x86)\Microsoft Visual Studio 14.0\Common7" \
                r"\IDE"
  defaultExecutable = "devenv.com"

  if not IsWindows():
    return defaultPath, defaultExecutable

  keyNamesAndExecutables = [
    # Pair for non-Express editions.
    (GetWindowsRegistryKeyName(r'Microsoft\VisualStudio'), 'devenv.com'),
    # Pair for 2012 Express edition.
    (GetWindowsRegistryKeyName(r'Microsoft\VSWinExpress'), 'VSWinExpress.exe'),
    # Pair for pre-2012 Express editions.
    (GetWindowsRegistryKeyName(r'Microsoft\VCExpress'), 'VCExpress.exe')]

  bestGuess = (0.0, (defaultPath, defaultExecutable))

  import _winreg
  for (keyName, executable) in keyNamesAndExecutables:
    try:
      key = _winreg.OpenKey(_winreg.HKEY_LOCAL_MACHINE, keyName)
    except WindowsError:
      # Can't find this key - moving on the next one.
      continue

    try:
      subkeyCounter = 0
      while True:
        try:
          subkeyName = _winreg.EnumKey(key, subkeyCounter)
          subkeyCounter += 1
        except WindowsError:
          # Reached end of enumeration. Moving on the next key.
          break

        match = re.match(r'^\d+\.\d+$', subkeyName)
        if match:
          with _winreg.OpenKey(key, subkeyName) as subkey:
            try:
              (installDir, registrytype) = _winreg.QueryValueEx(subkey,
                                                                'InstallDir')
            except WindowsError:
              # Can't find value under the key - continue to the next key.
              continue
            isExpress = executable != 'devenv.com'
            if not isExpress and subkeyName == '14.0':
              # Stop search since if we found non-Express VS2015 version
              # installed, which is preferred version.
              return installDir, executable
            else:
              version = float(subkeyName)
              # We prefer higher version of Visual Studio and given equal
              # version numbers we prefer non-Express edition.
              if version > bestGuess[0]:
                bestGuess = (version, (installDir, executable))
    finally:
      _winreg.CloseKey(key)
  return bestGuess[1]


# Returns true if we're running under Windows.
def IsWindows():
  return GuessOS() == 'win32'


# Reads a text file into an array of strings - one for each
# line. Strips comments in the process.
def ReadLinesFrom(name):
  result = []
  for line in open(name):
    if '#' in line:
      line = line[:line.find('#')]
    line = line.strip()
    if len(line) == 0:
      continue
    result.append(line)
  return result


# Filters out all arguments until the next '--' argument
# occurs.
def ListArgCallback(option, value, parser):
   if value is None:
     value = []

   for arg in parser.rargs:
     if arg[:2].startswith('--'):
       break
     value.append(arg)

   del parser.rargs[:len(value)]
   setattr(parser.values, option.dest, value)


# Filters out all argument until the first non '-' or the
# '--' argument occurs.
def ListDartArgCallback(option, value, parser):
   if value is None:
     value = []

   for arg in parser.rargs:
     if arg[:2].startswith('--') or arg[0] != '-':
       break
     value.append(arg)

   del parser.rargs[:len(value)]
   setattr(parser.values, option.dest, value)


# Mapping table between build mode and build configuration.
BUILD_MODES = {
  'debug': 'Debug',
  'release': 'Release',
  'product': 'Product',
}


# Mapping table between OS and build output location.
BUILD_ROOT = {
  'win32': os.path.join('out'),
  'linux': os.path.join('out'),
  'freebsd': os.path.join('out'),
  'macos': os.path.join('xcodebuild'),
}

ARCH_FAMILY = {
  'ia32': 'ia32',
  'x64': 'ia32',
  'arm': 'arm',
  'armv6': 'arm',
  'armv5te': 'arm',
  'arm64': 'arm',
  'simarm': 'ia32',
  'simarmv6': 'ia32',
  'simarmv5te': 'ia32',
  'simarm64': 'ia32',
  'simdbc': 'ia32',
  'simdbc64': 'ia32',
  'armsimdbc': 'arm',
  'armsimdbc64': 'arm',
  'simarm_x64': 'ia32',
}

ARCH_GUESS = GuessArchitecture()
BASE_DIR = os.path.abspath(os.path.join(os.curdir, '..'))
DART_DIR = os.path.abspath(os.path.join(__file__, '..', '..'))
VERSION_FILE = os.path.join(DART_DIR, 'tools', 'VERSION')


def GetBuildbotGSUtilPath():
  gsutil = '/b/build/scripts/slave/gsutil'
  if platform.system() == 'Windows':
    gsutil = 'e:\\\\b\\build\\scripts\\slave\\gsutil'
  return gsutil


def GetBuildMode(mode):
  return BUILD_MODES[mode]


def GetArchFamily(arch):
  return ARCH_FAMILY[arch]


def IsCrossBuild(target_os, arch):
  host_arch = ARCH_GUESS
  return ((GetArchFamily(host_arch) != GetArchFamily(arch)) or
          (target_os != GuessOS()))


def GetBuildConf(mode, arch, conf_os=None):
  if conf_os == 'android':
    return '%s%s%s' % (GetBuildMode(mode), conf_os.title(), arch.upper())
  else:
    # Ask for a cross build if the host and target architectures don't match.
    host_arch = ARCH_GUESS
    cross_build = ''
    if GetArchFamily(host_arch) != GetArchFamily(arch):
      cross_build = 'X'
    return '%s%s%s' % (GetBuildMode(mode), cross_build, arch.upper())


def GetBuildDir(host_os):
  return BUILD_ROOT[host_os]


def GetBuildRoot(host_os, mode=None, arch=None, target_os=None):
  build_root = GetBuildDir(host_os)
  if mode:
    build_root = os.path.join(build_root,
                              GetBuildConf(mode, arch, target_os))
  return build_root


def GetBuildSdkBin(host_os, mode=None, arch=None, target_os=None):
  build_root = GetBuildRoot(host_os, mode, arch, target_os)
  return os.path.join(build_root, 'dart-sdk', 'bin')


def GetBaseDir():
  return BASE_DIR


def GetShortVersion():
  version = ReadVersionFile()
  return ('%s.%s.%s.%s.%s' % (
      version.major, version.minor, version.patch, version.prerelease,
      version.prerelease_patch))


def GetSemanticSDKVersion(no_git_hash=False, version_file=None):
  version = ReadVersionFile(version_file)
  if not version:
    return None

  if version.channel == 'be':
    postfix = '-edge' if no_git_hash else '-edge.%s' % GetGitRevision()
  elif version.channel == 'dev':
    postfix = '-dev.%s.%s' % (version.prerelease, version.prerelease_patch)
  else:
    assert version.channel == 'stable'
    postfix = ''

  return '%s.%s.%s%s' % (version.major, version.minor, version.patch, postfix)


def GetVersion(no_git_hash=False, version_file=None):
  return GetSemanticSDKVersion(no_git_hash, version_file)


# The editor used to produce the VERSION file put on gcs. We now produce this
# in the bots archiving the sdk.
# The content looks like this:
#{
#  "date": "2015-05-28",
#  "version": "1.11.0-edge.131653",
#  "revision": "535394c2657ede445142d8a92486d3899bbf49b5"
#}
def GetVersionFileContent():
  result = {"date": str(datetime.date.today()),
            "version": GetVersion(),
            "revision": GetGitRevision()}
  return json.dumps(result, indent=2)


def GetChannel():
  version = ReadVersionFile()
  return version.channel


def GetUserName():
  key = 'USER'
  if sys.platform == 'win32':
    key = 'USERNAME'
  return os.environ.get(key, '')


def GetAbiVersion(version_file=None):
  version = ReadVersionFile(version_file)
  return version.abi_version


def GetOldestSupportedAbiVersion(version_file=None):
  version = ReadVersionFile(version_file)
  return version.oldest_supported_abi_version


def ReadVersionFile(version_file=None):
  def match_against(pattern, file_content):
    match = re.search(pattern, file_content, flags=re.MULTILINE)
    if match:
      return match.group(1)
    return None

  if version_file == None:
    version_file = VERSION_FILE

  try:
    fd = open(version_file)
    content = fd.read()
    fd.close()
  except:
    print ("Warning: Couldn't read VERSION file (%s)" % version_file)
    return None

  channel = match_against('^CHANNEL ([A-Za-z0-9]+)$', content)
  major = match_against('^MAJOR (\d+)$', content)
  minor = match_against('^MINOR (\d+)$', content)
  patch = match_against('^PATCH (\d+)$', content)
  prerelease = match_against('^PRERELEASE (\d+)$', content)
  prerelease_patch = match_against('^PRERELEASE_PATCH (\d+)$', content)
  abi_version = match_against('^ABI_VERSION (\d+)$', content)
  oldest_supported_abi_version = match_against(
      '^OLDEST_SUPPORTED_ABI_VERSION (\d+)$', content)

  if channel and major and minor and prerelease and prerelease_patch and \
      abi_version and oldest_supported_abi_version:
    return Version(
        channel, major, minor, patch, prerelease, prerelease_patch, abi_version,
        oldest_supported_abi_version)
  else:
    print ("Warning: VERSION file (%s) has wrong format" % version_file)
    return None


# Our schema for releases and archiving is based on an increasing
# sequence of numbers. In the svn world this was simply the revision of a
# commit, which would always give us a one to one mapping between the number
# and the commit. This was true across branches as well, so a number used
# to archive a build was always unique and unambiguous.
# In git there is no such global number, so we loosen the requirement a bit.
# We only use numbers on the master branch (bleeding edge). On branches
# we use the version number instead for archiving purposes.
# The number on master is the count of commits on the master branch.
def GetArchiveVersion():
  version = ReadVersionFile()
  if not version:
    raise 'Could not get the archive version, parsing the version file failed'
  if version.channel in ['be', 'integration']:
    return GetGitNumber()
  return GetSemanticSDKVersion()


def GetGitRevision():
  # When building from tarball use tools/GIT_REVISION
  git_revision_file = os.path.join(DART_DIR, 'tools', 'GIT_REVISION')
  try:
    with open(git_revision_file) as fd:
      return fd.read()
  except:
    pass
  p = subprocess.Popen(['git', 'rev-parse', 'HEAD'],
                       stdout = subprocess.PIPE,
                       stderr = subprocess.STDOUT, shell=IsWindows(),
                       cwd = DART_DIR)
  output, _ = p.communicate()
  output = output.strip()
  # We expect a full git hash
  if len(output) != 40:
    print ("Warning: could not parse git commit, output was %s" % output)
    return None
  return output


def GetShortGitHash():
  p = subprocess.Popen(['git', 'rev-parse', '--short', 'HEAD'],
                       stdout = subprocess.PIPE,
                       stderr = subprocess.STDOUT, shell=IsWindows(),
                       cwd = DART_DIR)
  output, _ = p.communicate()
  output = output.strip()
  if p.wait() != 0:
    return None
  return output


def GetLatestDevTag():
  cmd = [
    'git',
    'for-each-ref',
    'refs/tags/*dev*',
    '--sort=-taggerdate',
    "--format=%(refname:lstrip=2)",
    '--count=1',
  ]
  p = subprocess.Popen(cmd,
                       stdout = subprocess.PIPE,
                       stderr = subprocess.STDOUT, shell=IsWindows(),
                       cwd = DART_DIR)
  output, _ = p.communicate()
  if p.wait() != 0:
    print ("Warning: Could not get the most recent dev branch tag %s" % output)
    return None
  return output.strip()


def GetGitTimestamp():
  p = subprocess.Popen(['git', 'log', '-n', '1', '--pretty=format:%cd'],
                       stdout = subprocess.PIPE,
                       stderr = subprocess.STDOUT, shell=IsWindows(),
                       cwd = DART_DIR)
  output, _ = p.communicate()
  if p.wait() != 0:
    return None
  return output


# To eliminate clashing with older archived builds on bleeding edge we add
# a base number bigger the largest svn revision (this also gives us an easy
# way of seeing if an archive comes from git based or svn based commits).
GIT_NUMBER_BASE = 100000
def GetGitNumber():
  p = subprocess.Popen(['git', 'rev-list', 'HEAD', '--count'],
                       stdout = subprocess.PIPE,
                       stderr = subprocess.STDOUT, shell=IsWindows(),
                       cwd = DART_DIR)
  output, _ = p.communicate()
  try:
    number = int(output)
    return number + GIT_NUMBER_BASE
  except:
    print ("Warning: could not parse git count, output was %s" % output)
  return None


def ParseGitInfoOutput(output):
  """Given a git log, determine the latest corresponding svn revision."""
  for line in output.split('\n'):
    tokens = line.split()
    if len(tokens) > 0 and tokens[0] == 'git-svn-id:':
      return tokens[1].split('@')[1]
  return None


def ParseSvnInfoOutput(output):
  revision_match = re.search('Last Changed Rev: (\d+)', output)
  if revision_match:
    return revision_match.group(1)
  return None


def RewritePathSeparator(path, workspace):
  # Paths in test files are always specified using '/'
  # as the path separator. Replace with the actual
  # path separator before use.
  if '/' in path:
    split_path = path.split('/')
    path = os.sep.join(split_path)
    path = os.path.join(workspace, path)
    if not os.path.exists(path):
      raise Exception(path)
  return path


def ParseTestOptions(pattern, source, workspace):
  match = pattern.search(source)
  if match:
    return [RewritePathSeparator(o, workspace) for o in match.group(1).split(' ')]
  else:
    return None


def ParseTestOptionsMultiple(pattern, source, workspace):
  matches = pattern.findall(source)
  if matches:
    result = []
    for match in matches:
      if len(match) > 0:
        result.append(
            [RewritePathSeparator(o, workspace) for o in match.split(' ')])
      else:
        result.append([])
    return result
  else:
    return None


def Daemonize():
  """
  Create a detached background process (daemon). Returns True for
  the daemon, False for the parent process.
  See: http://www.faqs.org/faqs/unix-faq/programmer/faq/
  "1.7 How do I get my program to act like a daemon?"
  """
  if os.fork() > 0:
    return False
  os.setsid()
  if os.fork() > 0:
    exit(0)
    raise
  return True


def PrintError(string):
  """Writes and flushes a string to stderr."""
  sys.stderr.write(string)
  sys.stderr.write('\n')


def CheckedUnlink(name):
  """Unlink a file without throwing an exception."""
  try:
    os.unlink(name)
  except OSError as e:
    PrintError("os.unlink() " + str(e))


def Main():
  print ("GuessOS() -> ", GuessOS())
  print ("GuessArchitecture() -> ", GuessArchitecture())
  print ("GuessCpus() -> ", GuessCpus())
  print ("IsWindows() -> ", IsWindows())
  print ("GuessVisualStudioPath() -> ", GuessVisualStudioPath())
  print ("GetGitRevision() -> ", GetGitRevision())
  print ("GetGitTimestamp() -> ", GetGitTimestamp())
  print ("GetVersionFileContent() -> ", GetVersionFileContent())
  print ("GetGitNumber() -> ", GetGitNumber())


class Error(Exception):
  pass


class ToolError(Exception):
  """Deprecated exception, use Error instead."""

  def __init__(self, value):
    self.value = value

  def __str__(self):
    return repr(self.value)


def IsCrashExitCode(exit_code):
  if IsWindows():
    return 0x80000000 & exit_code
  else:
    return exit_code < 0


def DiagnoseExitCode(exit_code, command):
  if IsCrashExitCode(exit_code):
    sys.stderr.write('Command: %s\nCRASHED with exit code %d (0x%x)\n' % (
        ' '.join(command), exit_code, exit_code & 0xffffffff))


def Touch(name):
  with file(name, 'a'):
    os.utime(name, None)


def ExecuteCommand(cmd):
  """Execute a command in a subprocess."""
  print ('Executing: ' + ' '.join(cmd))
  pipe = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
      shell=IsWindows())
  output = pipe.communicate()
  if pipe.returncode != 0:
    raise Exception('Execution failed: ' + str(output))
  return pipe.returncode, output


# The checked-in SDKs are documented at
#     https://github.com/dart-lang/sdk/wiki/The-checked-in-SDK-in-tools
def CheckedInSdkPath():
  # We don't use the normal macos, linux, win32 directory names here, instead,
  # we use the names that the download_from_google_storage script uses.
  osdict = {'Darwin':'mac', 'Linux':'linux', 'Windows':'win'}
  system = platform.system()
  try:
    osname = osdict[system]
  except KeyError:
    sys.stderr.write('WARNING: platform "%s" not supported\n' % system)
    return None
  tools_dir = os.path.dirname(os.path.realpath(__file__))
  return os.path.join(tools_dir,
                      'sdks',
                      'dart-sdk')


def CheckedInSdkExecutable():
  name = 'dart'
  if IsWindows():
    name = 'dart.exe'
  return os.path.join(CheckedInSdkPath(), 'bin', name)

def CheckedInSdkCheckExecutable():
  executable = CheckedInSdkExecutable()
  canary_script = os.path.join(os.path.dirname(os.path.realpath(__file__)),
                               'canary.dart')
  try:
    with open(os.devnull, 'wb') as silent_sink:
      if 0 == subprocess.call([executable, canary_script], stdout=silent_sink):
        return True
  except OSError as e:
    pass
  return False


def CheckLinuxCoreDumpPattern(fatal=False):
  core_pattern_file = '/proc/sys/kernel/core_pattern'
  core_pattern = open(core_pattern_file).read()

  expected_core_pattern = 'core.%p'
  if core_pattern.strip() != expected_core_pattern:
    message = ('Invalid core_pattern configuration. '
        'The configuration of core dump handling is *not* correct for '
        'a buildbot. The content of {0} must be "{1}" instead of "{2}".'
        .format(core_pattern_file, expected_core_pattern, core_pattern))
    if fatal:
      raise Exception(message)
    else:
      print (message)
      return False
  return True


class TempDir(object):
  def __init__(self, prefix=''):
    self._temp_dir = None
    self._prefix = prefix

  def __enter__(self):
    self._temp_dir = tempfile.mkdtemp(self._prefix)
    return self._temp_dir

  def __exit__(self, *_):
    shutil.rmtree(self._temp_dir, ignore_errors=True)


class ChangedWorkingDirectory(object):
  def __init__(self, working_directory):
    self._working_directory = working_directory

  def __enter__(self):
    self._old_cwd = os.getcwd()
    print ("Enter directory = ", self._working_directory)
    os.chdir(self._working_directory)

  def __exit__(self, *_):
    print ("Enter directory = ", self._old_cwd)
    os.chdir(self._old_cwd)


class UnexpectedCrash(object):
  def __init__(self, test, pid, *binaries):
    self.test = test
    self.pid = pid
    self.binaries = binaries

  def __str__(self):
    return "Crash(%s: %s %s)" % (self.test, self.pid, ', '.join(self.binaries))


class PosixCoreDumpEnabler(object):
  def __init__(self):
    self._old_limits = None

  def __enter__(self):
    self._old_limits = resource.getrlimit(resource.RLIMIT_CORE)
    resource.setrlimit(resource.RLIMIT_CORE, (-1, -1))

  def __exit__(self, *_):
    resource.setrlimit(resource.RLIMIT_CORE, self._old_limits)


class LinuxCoreDumpEnabler(PosixCoreDumpEnabler):
  def __enter__(self):
    # Bump core limits to unlimited if core_pattern is correctly configured.
    if CheckLinuxCoreDumpPattern(fatal=False):
      super(LinuxCoreDumpEnabler, self).__enter__()

  def __exit__(self, *args):
    CheckLinuxCoreDumpPattern(fatal=False)
    super(LinuxCoreDumpEnabler, self).__exit__(*args)


class WindowsCoreDumpEnabler(object):
  """This enabler assumes that Dart binary was built with Crashpad support.
  In this case DART_CRASHPAD_CRASHES_DIR environment variable allows to
  specify the location of Crashpad crashes database. Actual minidumps will
  be written into reports subfolder of the database.
  """
  CRASHPAD_DB_FOLDER = os.path.join(DART_DIR, r'crashes')
  DUMPS_FOLDER = os.path.join(CRASHPAD_DB_FOLDER, r'reports')

  def __init__(self):
    pass

  def __enter__(self):
    print ("INFO: Enabling coredump archiving into %s" % (WindowsCoreDumpEnabler.CRASHPAD_DB_FOLDER))
    os.environ['DART_CRASHPAD_CRASHES_DIR'] = WindowsCoreDumpEnabler.CRASHPAD_DB_FOLDER

  def __exit__(self, *_):
    del os.environ['DART_CRASHPAD_CRASHES_DIR']


def TryUnlink(file):
  try:
    os.unlink(file)
  except Exception as error:
    print ("ERROR: Failed to remove %s: %s" % (file, error))


class BaseCoreDumpArchiver(object):
  """This class reads coredumps file written by UnexpectedCrashDumpArchiver
  into the current working directory and uploads all cores and binaries
  listed in it into Cloud Storage (see
  pkg/test_runner/lib/src/test_progress.dart).
  """

  # test.dart will write a line for each unexpected crash into this file.
  _UNEXPECTED_CRASHES_FILE = "unexpected-crashes"

  def __init__(self, search_dir, output_directory):
    self._bucket = 'dart-temp-crash-archive'
    self._binaries_dir = os.getcwd()
    self._search_dir = search_dir
    self._output_directory = output_directory

  def _safe_cleanup(self):
    try:
      return self._cleanup();
    except Exception as error:
      print ("ERROR: Failure during cleanup: %s" % error)
      return False

  def __enter__(self):
    print ("INFO: Core dump archiving is activated")

    # Cleanup any stale files
    if self._safe_cleanup():
      print ("WARNING: Found and removed stale coredumps")

  def __exit__(self, *_):
    try:
      crashes = self._find_unexpected_crashes()
      if crashes:
        # If we get a ton of crashes, only archive 10 dumps.
        archive_crashes = crashes[:10]
        print ('Archiving coredumps for crash (if possible):')
        for crash in archive_crashes:
          print ('----> %s' % crash)

        sys.stdout.flush()

        self._archive(archive_crashes)
      else:
        print ("INFO: No unexpected crashes recorded")
        dumps = self._find_all_coredumps()
        if dumps:
          print ("INFO: However there are %d core dumps found" % len(dumps))
          for dump in dumps:
            print ("INFO:        -> %s" % dump)
          print ()
    except Exception as error:
      print ("ERROR: Failed to archive crashes: %s" % error)
      raise

    finally:
      self._safe_cleanup()

  def _archive(self, crashes):
    files = set()
    missing = []
    for crash in crashes:
      files.update(crash.binaries)
      core = self._find_coredump_file(crash)
      if core:
        files.add(core)
      else:
        missing.append(crash)
    if self._output_directory is not None and self._is_shard():
      print (
          "INFO: Moving collected dumps and binaries into output directory\n"
          "INFO: They will be uploaded to isolate server. Look for \"isolated"
          " out\" under the failed step on the build page.\n"
          "INFO: For more information see runtime/docs/infra/coredumps.md")
      self._move(files)
    else:
      print (
          "INFO: Uploading collected dumps and binaries into Cloud Storage\n"
          "INFO: Use `gsutil.py cp from-url to-path` to download them.\n"
          "INFO: For more information see runtime/docs/infra/coredumps.md")
      self._upload(files)

    if missing:
      self._report_missing_crashes(missing, throw=True)

  # todo(athom): move the logic to decide where to copy core dumps into the recipes.
  def _is_shard(self):
    return 'BUILDBOT_BUILDERNAME' not in os.environ

  def _report_missing_crashes(self, missing, throw=True):
    missing_as_string = ', '.join([str(c) for c in missing])
    other_files = list(glob.glob(os.path.join(self._search_dir, '*')))
    sys.stderr.write(
        "Could not find crash dumps for '%s' in search directory '%s'.\n"
        "Existing files which *did not* match the pattern inside the search "
        "directory are are:\n  %s\n"
        % (missing_as_string, self._search_dir, '\n  '.join(other_files)))
    # TODO: Figure out why windows coredump generation does not work.
    # See http://dartbug.com/36469
    if throw and GuessOS() != 'win32':
      raise Exception('Missing crash dumps for: %s' % missing_as_string)

  def _get_file_name(self, file):
    # Sanitize the name: actual cores follow 'core.%d' pattern, crashed
    # binaries are copied next to cores and named
    # 'binary.<mode>_<arch>_<binary_name>'.
    # This should match the code in testing/dart/test_progress.dart
    name = os.path.basename(file)
    (prefix, suffix) = name.split('.', 1)
    is_binary = prefix == 'binary'
    if is_binary:
      (mode, arch, binary_name) = suffix.split('_', 2)
      name = binary_name
    return (name, is_binary)

  def _move(self, files):
    for file in files:
      print ('+++ Moving %s to output_directory (%s)' % (file, self._output_directory))
      (name, is_binary) = self._get_file_name(file)
      destination = os.path.join(self._output_directory, name)
      shutil.move(file, destination)
      if is_binary and os.path.exists(file + '.pdb'):
        # Also move a PDB file if there is one.
        pdb = os.path.join(self._output_directory, name + '.pdb')
        shutil.move(file + '.pdb', pdb)

  def _tar(self, file):
    (name, is_binary) = self._get_file_name(file)
    tarname = '%s.tar.gz' % name

    # Compress the file.
    tar = tarfile.open(tarname, mode='w:gz')
    tar.add(file, arcname=name)
    if is_binary and os.path.exists(file + '.pdb'):
      # Also add a PDB file if there is one.
      tar.add(file + '.pdb', arcname=name + '.pdb')
    tar.close()
    return tarname

  def _upload(self, files):
    bot_utils = GetBotUtils()
    gsutil = bot_utils.GSUtil()
    storage_path = '%s/%s/' % (self._bucket, uuid.uuid4())
    gs_prefix = 'gs://%s' % storage_path
    http_prefix = 'https://storage.cloud.google.com/%s' % storage_path

    print ('\n--- Uploading into %s (%s) ---' % (gs_prefix, http_prefix))
    for file in files:
      tarname = self._tar(file)

      # Remove / from absolute path to not have // in gs path.
      gs_url = '%s%s' % (gs_prefix, tarname)
      http_url = '%s%s' % (http_prefix, tarname)

      try:
        gsutil.upload(tarname, gs_url)
        print ('+++ Uploaded %s (%s)' % (gs_url, http_url))
      except Exception as error:
        print ('!!! Failed to upload %s, error: %s' % (tarname, error))

      TryUnlink(tarname)

    print ('--- Done ---\n')

  def _find_all_coredumps(self):
    """Return coredumps that were recorded (if supported by the platform).
    This method will be overriden by concrete platform specific implementations.
    """
    return []

  def _find_unexpected_crashes(self):
    """Load coredumps file. Each line has the following format:

        test-name,pid,binary-file1,binary-file2,...
    """
    try:
      with open(BaseCoreDumpArchiver._UNEXPECTED_CRASHES_FILE) as f:
        return [UnexpectedCrash(*ln.strip('\n').split(',')) for ln in f.readlines()]
    except:
      return []

  def _cleanup(self):
    found = False
    if os.path.exists(BaseCoreDumpArchiver._UNEXPECTED_CRASHES_FILE):
      os.unlink(BaseCoreDumpArchiver._UNEXPECTED_CRASHES_FILE)
      found = True
    for binary in glob.glob(os.path.join(self._binaries_dir, 'binary.*')):
      found = True
      TryUnlink(binary)

    return found

class PosixCoreDumpArchiver(BaseCoreDumpArchiver):
  def __init__(self, search_dir, output_directory):
    super(PosixCoreDumpArchiver, self).__init__(search_dir, output_directory)

  def _cleanup(self):
    found = super(PosixCoreDumpArchiver, self)._cleanup()
    for core in glob.glob(os.path.join(self._search_dir, 'core.*')):
      found = True
      TryUnlink(core)
    return found

  def _find_coredump_file(self, crash):
    core_filename = os.path.join(self._search_dir, 'core.%s' % crash.pid)
    if os.path.exists(core_filename):
      return core_filename


class LinuxCoreDumpArchiver(PosixCoreDumpArchiver):
  def __init__(self, output_directory):
    super(LinuxCoreDumpArchiver, self).__init__(os.getcwd(), output_directory)


class MacOSCoreDumpArchiver(PosixCoreDumpArchiver):
  def __init__(self, output_directory):
    super(MacOSCoreDumpArchiver, self).__init__('/cores', output_directory)


class WindowsCoreDumpArchiver(BaseCoreDumpArchiver):
  def __init__(self, output_directory):
    super(WindowsCoreDumpArchiver, self).__init__(
        WindowsCoreDumpEnabler.DUMPS_FOLDER, output_directory)
    self._dumps_by_pid = None

  # Find CDB.exe in the win_toolchain that we are using.
  def _find_cdb(self):
    win_toolchain_json_path = os.path.join(
        DART_DIR, 'build', 'win_toolchain.json')
    if not os.path.exists(win_toolchain_json_path):
      return None

    with open(win_toolchain_json_path, "r") as f:
      win_toolchain_info = json.loads(f.read())

    win_sdk_path = win_toolchain_info['win_sdk']

    # We assume that we are running on 64-bit Windows.
    # Note: x64 CDB can work with both X64 and IA32 dumps.
    cdb_path = os.path.join(win_sdk_path, 'Debuggers', 'x64', 'cdb.exe')
    if not os.path.exists(cdb_path):
      return None

    return cdb_path

  CDBG_PROMPT_RE = re.compile(r'^\d+:\d+>')

  def _dump_all_stacks(self):
    # On Windows due to crashpad integration crashes do not produce any
    # stacktraces. Dump stack traces from dumps Crashpad collected using
    # CDB (if available).
    cdb_path = self._find_cdb()
    if cdb_path is None:
      return

    dumps = self._find_all_coredumps()
    if not dumps:
      return

    print ("### Collected %d crash dumps" % len(dumps))
    for dump in dumps:
      print ()
      print ("### Dumping stacks from %s using CDB" % dump)
      cdb_output = subprocess.check_output(
          '"%s" -z "%s" -kqm -c "!uniqstack -b -v -p;qd"' % (cdb_path, dump),
          stderr=subprocess.STDOUT)
      # Extract output of uniqstack from the whole output of CDB.
      output = False
      for line in cdb_output.split('\n'):
        if re.match(WindowsCoreDumpArchiver.CDBG_PROMPT_RE, line):
          output = True
        elif line.startswith("quit:"):
          break
        elif output:
          print (line)
    print ()
    print ("#############################################")
    print ()


  def __exit__(self, *args):
    try:
      self._dump_all_stacks()
    except Exception as error:
      print ("ERROR: Unable to dump stacks from dumps: %s" % error)

    super(WindowsCoreDumpArchiver, self).__exit__(*args)


  def _cleanup(self):
    found = super(WindowsCoreDumpArchiver, self)._cleanup()
    for core in glob.glob(os.path.join(self._search_dir, '*')):
      found = True
      TryUnlink(core)
    return found

  def _find_all_coredumps(self):
    pattern = os.path.join(self._search_dir, '*.dmp')
    return [core_filename for core_filename in glob.glob(pattern)]

  def _find_coredump_file(self, crash):
    if self._dumps_by_pid is None:
      # If this function is invoked the first time then look through the directory
      # that contains crashes for all dump files and collect pid -> filename
      # mapping.
      self._dumps_by_pid = {}
      minidump = GetMinidumpUtils()
      pattern = os.path.join(self._search_dir, '*.dmp')
      for core_filename in glob.glob(pattern):
        pid = minidump.GetProcessIdFromDump(core_filename)
        if pid != -1:
          self._dumps_by_pid[str(pid)] = core_filename
    if crash.pid in self._dumps_by_pid:
      return self._dumps_by_pid[crash.pid]

  def _report_missing_crashes(self, missing, throw=True):
    # Let's only print the debugging information and not throw. We'll do more
    # validation for werfault.exe and throw afterwards.
    super(WindowsCoreDumpArchiver, self)._report_missing_crashes(missing, throw=False)

    if throw:
      missing_as_string = ', '.join([str(c) for c in missing])
      raise Exception('Missing crash dumps for: %s' % missing_as_string)

class IncreasedNumberOfFileDescriptors(object):
  def __init__(self, nofiles):
    self._old_limits = None
    self._limits = (nofiles, nofiles)

  def __enter__(self):
    self._old_limits = resource.getrlimit(resource.RLIMIT_NOFILE)
    resource.setrlimit(resource.RLIMIT_NOFILE, self._limits)

  def __exit__(self, *_):
    resource.setrlimit(resource.RLIMIT_CORE, self._old_limits)

@contextlib.contextmanager
def NooptContextManager():
  yield


def CoreDumpArchiver(args):
  enabled = '--copy-coredumps' in args
  prefix = '--output-directory='
  output_directory = next((arg[len(prefix):] for arg in args if arg.startswith(prefix)), None)

  if not enabled:
    return NooptContextManager()

  osname = GuessOS()
  if osname == 'linux':
    return contextlib.nested(LinuxCoreDumpEnabler(),
                             LinuxCoreDumpArchiver(output_directory))
  elif osname == 'macos':
    return contextlib.nested(PosixCoreDumpEnabler(),
                             MacOSCoreDumpArchiver(output_directory))
  elif osname == 'win32':
    return contextlib.nested(WindowsCoreDumpEnabler(),
                             WindowsCoreDumpArchiver(output_directory))
  else:
    # We don't have support for MacOS yet.
    return NooptContextManager()

def FileDescriptorLimitIncreaser():
  osname = GuessOS()
  if osname == 'macos':
    return IncreasedNumberOfFileDescriptors(nofiles=10000)
  else:
    assert osname in ('linux', 'win32')
    # We don't have support for MacOS yet.
    return NooptContextManager()

if __name__ == "__main__":
  import sys
  Main()
